Repository navigation
(agents): a view of the sessions the claude daemon runs in the background - #374
Conversation
A graphical replacement for the claude agents TUI: a dedicated view fed by the daemon's job files and the session descriptors, reconciled by claude agents --json, with attach/stop/rm/respawn/dispatch through the CLI.
…e agents view Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…econciled by the CLI Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…ose the agents roster open-terminal runs `claude attach <id>` for a validated job id (no resume, sandbox, pre-launch or MCP), stop-session detaches an attach tab instead of killing it, and the roster module is wired to IPC, the preload API and the window teardown. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
… does not silence it bgAgents.stop() clears its listeners when the window closes; a re-created window's next get-bg-agents now restores the bg-agents-changed push, idempotently. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…fering to resume it Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…anscript, stop, respawn and delete Lists the roster the main process keeps, live jobs (working or blocked) first, with the verbs each state allows; opening the view is what arms the roster push. Ctrl/Cmd+Shift+A toggles it. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…er viewer opens A hide of an already-hidden view no longer clears the persisted flag, and the flag is read before the working-set restore runs. Memory, Work Files and Settings now close the view instead of stacking over it. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
The New agent button opens a dialog taking the prompt, project, name, agent and the New-Session permission options, and hands them to dispatchBgAgent. A refusal from main stays inline; a success closes the dialog and refreshes the roster, selecting the new row when the id is known. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
User doc, context doc with the known limits, and the rows in the README, shortcuts, IPC and cli-session-state docs. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
escapeHtml leaves double quotes alone, so a quote in a cwd, href or session id could close the attribute and inject a data-verb that a click would run. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
The CLI runs through an interactive login shell, so rc files that print to stdout made every reconcile fail and the view blamed the daemon. The list parse now falls back to the line-bounded JSON array inside the noise, and a verb's error drops the shell's job-control warnings. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
A finished job kept its bg badge and its "click to attach" tooltip, while a click on it resumes the session normally. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
The reattach branch of open-terminal did not say the live session was an attach, so after a renderer reload the tab was treated as an ordinary session. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
rm ran inside the job's cwd, which may be the directory it deletes; Windows refuses to remove a live process's cwd. Respawn keeps the job cwd, where its brief needs it. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…tch dialog Enter on Cancel both closed the dialog and started the agent. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
The CLI's --bg output and its untrusted-workspace refusal were measured on 2.1.285; the stale unmeasured note goes, and the context doc now covers the tolerant list parse, rm from home, the live-only badge, the reattach flag and the attribute escaping. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
The daemon reports state failed for a job that ended in error (CLI 2.1.285, two live jobs); JOB_STATES dropped it to null and the row read '?'. It is finished like done and stopped: not live, filtered by Finished, Respawn and Delete enabled. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
A Group select in the view header (None / State / Project) splits the list into sections with a header and a count. The Finished filter and the sort apply first; headers are not rows, so selection and clicks are unchanged. The choice persists in localStorage.agentsGroupBy. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
Unset or invalid agentsGroupBy now means State; a stored none or project is kept. One AGENT_STATE_META map gives each state its emoji and label, used by the State headers and at the start of every row's state column. Section headers are larger and semi-bold, the count secondary. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…ree sub-groups Project mode keyed every cwd apart, so each worktree of a repo formed its own group. The main process now resolves projectRoot and worktreeRoot per cwd (the .claude/worktrees pattern, else one git rev-parse through execFile, cached, never blocking the roster) and Project mode groups by projectRoot. A Worktrees option, on by default and remembered, sub-groups a project by worktree when it spans more than one. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
Every group header (State, Project, worktree sub-group) is now a toggle: click, Enter or Space hides its rows, keeping the label and count. Keys are scoped by mode and level and persist in localStorage.agentsCollapsedGroups (capped at 200); roster pushes, regroups and the Finished filter keep them, and the selection stays. Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
|
Reviewing |
|
Thanks for the approval. I had already merged main again before it landed: On the attach flag through #402's generation path: an attach tab keeps it. The renderer sets |
devsuitup
left a comment
There was a problem hiding this comment.
Re-review at 792481d: a merge of current main (through #404). The resolution is right where both sides met. detachPty goes through writePty, so it inherits the #408 kill-once and no-write-after-kill guards, and the open-terminal replies carry both attach and generation. Full local task check at 792481d: lint 0 errors; 3136 tests, 0 failures. Approving.
|
CI and live test at CI: one test is red on Linux; Windows, lint and changelog are green. Live test against the real daemon passed. Run on Windows 11 with CLI 2.1.287 and the real
Non-blocking follow-ups seen live (fine as separate issues):
|
devsuitup
left a comment
There was a problem hiding this comment.
Review at 792481d. The head is unchanged since 2 October, and the Linux test is still red. It is also 37 commits behind main, with textual conflicts in main.js, public/style.css, CHANGELOG.md and .ai/contexts/ipc-bridge.md, and 16 other files changed on both sides. The points below were found on the PR tree and re-checked by reading the code.
Blocking
- Linux CI (
cli-session-state.test.js:467, "liveElsewhere reports the descriptor kind and jobId ..."). The fixture writes a descriptor for the fake PID 4242, butliveElsewhereprobes the real/procfor that PID's start time and ancestry. On a Linux runner a real process can own PID 4242, so the probe returns null. Windows has no such probe, which is why it passes there. I could not reproduce it without the CI logs, so this is the likely cause rather than a proven one. Fix: injectreadProcStart,readParentPid,ownPidand the platform into the synthetic fixture, as the neighbouring tests do, and keep the production PID-reuse protection. - Dispatch dialog keeps the first project's settings (
public/dialogs.js,showDispatchAgentDialog).effectiveis read once for the opening project. The project<select>has no change handler, so switching project keeps that project's Dangerous Skip choice and additional directories. An agent can then be dispatched with--dangerously-skip-permissionsin a project that never enabled it. Recompute them when the project changes. - Dispatch drops the sandbox and the pre-launch command (
bg-agents-roster.js,dispatchArgs). It forwards the permission mode,--dangerously-skip-permissionsand--add-dir, but nothing for a configured sandbox or pre-launch command. A project that runs its sessions sandboxed gets an unsandboxed background agent, without any message. Either carry them over, or refuse and say why. - Deleting a session can remove a live background worker's transcript (
main.js,delete-session). The guard isactiveSessions.has(id), which only knows the app's own terminals. A session that a daemon job is running is not in that map.
Non-blocking
- Remote project paths lose their host when they are passed to the dispatch dialog and become local destinations.
- A global WSL profile can point at a different daemon from the one the Windows roster reads.
- Live jobs keep the Fork action, and archiving can treat a client detach as the worker ending.
- Rationale comments in
claude-binary.jsandagents-view.js, repeated defaults, and the new styles do not use the shared control classes of #468.
Rebase hazards beyond the textual conflicts. The attach and resize paths must keep main's options: attach, generation, remoteResizeAllowed and the Refresh control (#453, #455). The folder archive handling of #482 needs a decision for background workers. The trigger delivery of #439 and the restore planner assume session ids that the Agents view can now replace.
Verified: 206 related tests pass on Windows, Node 24 (2 Linux-only tests skipped). Not verified: Ubuntu Node 20/22, c8, and the merged result.
Not mergeable as it stands. Once the points above are addressed and the branch is rebased on current main, I will review the new head.
…-agents-view-impl Conflicts: CHANGELOG.md (the Agents entry moves to the new Unreleased section), main.js (keeps the bg-agents requires next to main's terminal-resize handler; resizePty is no longer used here), public/style.css (the frameless no-drag lists keep the Agents header labels and take main's .modal-overlay), .ai/contexts/ipc-bridge.md (both sections kept; bg-agents-changed added to the event list). main's theme-controls test requires every index.html button to be styled through a class: New agent gets .agents-header-btn instead of its id. Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
…e kind test The test wrote a descriptor for pid 4242 and let the real /proc probe run. On a CI runner where pid 4242 exists, its start time differs from the descriptor's procStart, so the descriptor was dropped and liveElsewhere returned null. boot() now forwards readProcStart and readParentPid, and the test passes a probe that matches the descriptor. Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
|
Thanks for the live test. Pushed Red Linux test. Merge of main ( The four non-blocking follow-ups (attach TUI size, newer-source state, dispatch error noise, slow delete) are left for separate issues as you suggested. |
devsuitup
left a comment
There was a problem hiding this comment.
Review at 2150fd6. Thanks for the rebase on main and for the test fix: injecting readProcStart and readParentPid into boot() addresses the fake-PID collision I suspected. CI on this head was only just approved, so the Linux result is not in yet.
Three of the earlier blocking points are still there, checked by reading the PR tree:
- Dispatch dialog keeps the first project's settings (
public/dialogs.js,showDispatchAgentDialog).effectiveis still read once, at line 177, and the project<select>has no change handler, so switching project keeps the first project's Dangerous Skip choice and additional directories. - Dispatch still drops the sandbox and the pre-launch command (
bg-agents-roster.js,dispatchArgs). Nothing about either appears in the file, so a project that runs sandboxed gets an unsandboxed background agent without any message. delete-sessionis still guarded byactiveSessions.has(id)only (main.js). A session run by a daemon job is not in that map, so its transcript can be deleted under a live worker.
The non-blocking points from the previous review are unchanged. I will review again once these are addressed and the Linux tests are green on the exact head.
…ed dispatch and live deletes The dispatch dialog read the effective settings once, for the opening project: switching project kept its Dangerous Skip choice and additional directories. It now re-reads them on every project change, and no longer offers remote projects, whose path would become a local destination. The daemon starts a background worker itself, so a project's sandbox and pre-launch command cannot apply to it. Dispatch in such a project is now refused, main-side and in the dialog, instead of running the agent unconfined without a word. delete-session only knew the app's own terminals, so a transcript a daemon job is still writing could be deleted. It now also refuses a session that a live roster job or another process holds. The liveElsewhere kind test also pins ownPid and the platform. Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
|
Thanks. I had missed your review of this morning before pushing Blocking
Non-blocking
Rebase hazards: the |
…y's rationale to the context doc Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
|
Follow-up |
devsuitup
left a comment
There was a problem hiding this comment.
Review at d61369a. Thanks: the three earlier blockers are addressed in shape. The dialog follows the chosen project with a stale-response guard, dispatch refuses an exact-project sandbox or pre-launch command, and delete-session now asks the roster and the descriptors. The branch is level with main. Three points still block; the reviewer reproduced each with the shipped code, and I re-read the paths.
Blocking
delete-sessiondoes not fail closed when liveness is unknown (main.js, theliveJobForSession || liveElsewherecheck;bg-agents.jsliveJobForSession). With the roster absent or stale,liveJobForSessionreturns null, andliveElsewherereturns nothing when the descriptor directory cannot be read. The transcript of a session a daemon is still running is then deleted. Fix: refuse when the roster has not been loaded or the descriptor directory cannot be read, and say why. Test: no roster loaded plus an unreadable descriptor directory must refuse.- An uppercase session id bypasses both liveness checks (
bg-agents.jsliveJobForSession,e.sessionId === sessionId;cli-session-state.jssessionIds.has(raw.sessionId)). On Windows the same transcript resolves case-insensitively, so a differently cased id passes both matches and the delete goes through. Fix: normalise the id (lowercase) on both sides before comparing. Test: delete with an uppercase id while a live job holds the lowercase one. - A failed dispatch re-enables Start while the next project's settings lookup is pending (
public/dialogs.js,start()).startBtn.disabled = falseruns unconditionally after the call returns. If the user has switched project in the meantime, Retry sends the newcwdwith the previous project's Dangerous Skip and additional directories. Fix: re-enable only when no lookup is pending, or send the fields of the project that was last resolved. Test: fail a dispatch, switch project with an unresolved lookup, and check that Start stays disabled.
Non-blocking
test/delete-session.test.jsandtest/dom-dispatch-dialog.test.jsassert on source strings and use lookups that resolve at once, so they would not fail if these guards were removed.
217 tests in the 18 related files pass locally on Node 24. Not run: Linux Node 20/22, c8, lint, fresh CI. CI on this head still needs its approval; I will approve it once the points above are fixed.
… hold Start during a project lookup delete-session now asks delete-session-guard.js. It refuses when it cannot tell whether a session is live: no roster and an unreadable jobs directory, an unreadable descriptor directory or state, or a live job whose state names no session. liveJobCheck reads the job files directly, so the check works before the Agents view is ever opened. Session ids are compared lowercased in the roster, the job files, the descriptors and the open terminals: Windows resolves a transcript path case-insensitively, so an uppercase id passed every check. A failed dispatch re-enabled Start while the next project's settings were loading; a retry sent the new cwd with the previous project's options. Start now stays disabled while a lookup is pending, and dispatch sends the project whose settings were last applied. Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
|
Thanks. Pushed
Tests.
The new dialog test holds the second project's lookup unresolved while a dispatch fails. I checked that the tests catch regressions: each of the four guards (lowercasing in both modules, fail-closed in both) and the old unconditional |
devsuitup
left a comment
There was a problem hiding this comment.
Review at 966878e. The delete guard now exists as a shared check, ids are matched in any case in the two liveness lookups, and the failed-dispatch case with a pending lookup now holds Start. Three points still block; each was reproduced by the reviewer with the shipped code and I re-read the paths.
Blocking
- An unrecognised job state counts as "not live" (
bg-agents.jsliveJobCheck,bg-agents-roster.jsisLiveJobState).isLiveJobStateis true only forworkingandblocked, andliveJobChecktreats anything else as a finished job, so a state the daemon adds later (or a typo instate.json) returns{ known: true, job: null }and the delete goes through. Liveness is unknown in that case, not empty: returnknown: falsewhen the state is not one of the listedJOB_STATES. Test: astate.jsonwith a state outsideJOB_STATESmust refuse. - A stale cached
workingroster entry overrides a freshdonefile (bg-agents.js, thefromRosterbranch). The roster match returns before the job files are read. If the observation fails (the Agents view hidden, the watcher stopped), the cache staysworkingforever and the session can never be deleted. Prefer the job file when it can be read, and use the cache only as a fallback. Test: cachedworkingplus adonefile on disk must allow the delete. - A rejected settings lookup becomes
{}and re-enables Start (public/dialogs.js,effectiveForcatch). The dialog then dispatches with empty settings (no Dangerous Skip choice, no additional directories) as if the project had none. Keep Start disabled and show the error when the lookup fails. Test: rejectgetEffectiveSettingsand check that Start stays disabled.
Non-blocking
- Case-sensitive id lookups remain in the status caches and the badge sets (
cli-session-state.js~335,public/agents-view.js~189), so a differently cased id still misses its badge. test/delete-session.test.jsstill asserts on source strings: replacing the endpoint's liveness callbacks with known-empty stubs leaves it green.
206 related tests pass locally on Node 24, plus 66 in complementary files (2 Linux-only skipped). Not run: Linux Node 20/22, c8, lint, fresh CI. CI on this head is not approved yet; I will approve it once the points above are fixed.
…known states and failed settings lookups liveJobCheck read the cached roster first, so a stale "working" entry blocked a delete forever after the job file said "done". The job files now decide; the roster only supplies a session id a file lacks, or answers when jobs/ does not exist. A state outside JOB_STATES is unknown, not finished, and refuses the delete. The dispatch dialog turned a rejected settings lookup into empty settings and re-enabled Start. It now keeps Start disabled and shows the error. main builds the delete guard with makeDeleteSessionGuard from the real modules, which the guard tests use too. The bg badge matches session ids in any case. Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
|
Thanks. Pushed
Each of the three fixes, when reverted, turns its test red: 1, 1 and 2 failures. Non-blocking
|
devsuitup
left a comment
There was a problem hiding this comment.
Review at 37ebdb4. The three blockers of 966878e are fixed: an unknown or missing state refuses the delete, a readable job file wins over a stale cached working entry, and a rejected settings lookup keeps Start disabled, on the first lookup and after a project change, with a later successful lookup recovering it. One point still blocks, found by the reviewer and confirmed by reading.
Blocking
- A cached live job is ignored when its
state.jsonis missing (bg-agents.js,liveJobCheck). The cache is consulted only whenjobs/itself does not exist. Whenjobs/exists but a job's directory orstate.jsonis absent (ENOENTon the read), the loop doescontinueand never looks at the roster, so a job that the roster lists asworkingis treated as not running and the delete goes through. Fix: when the file is missing, fall back to the cached roster entry for that job id (live and same session means refuse). Test: cachedworkingentry withjobs/present and nostate.jsonfor it must refuse.
Non-blocking
cli-session-state.js(~335): the status-cache keys are still case-sensitive, so an uppercase id misses the busy status of a lowercase descriptor.public/sidebar.js(~1170): archiving a project folder archives background sessions after detaching their clients, while their workers keep running. This needs a decision against main's folder archive from #482 (refuse, or stop the worker first).test/delete-session.test.jsstill asserts on source strings for the endpoint wiring; replacing the liveness callbacks with known-empty stubs would leave it green.
214 required tests and 130 complementary ones pass locally on Node 24 (2 Linux-only skipped). Not run: Linux Node 20/22, c8, lint, fresh CI. CI on this head is not approved yet; I will approve it once the blocking point is fixed.
… archiving a live job's session liveJobCheck fell back to the roster only when jobs/ did not exist. A listed live job whose directory or state.json is missing now refuses the delete too. Archiving a session a live daemon job runs, alone or with its folder (devsuitup#482), detached the client and left the worker running under an archived session. stopBeforeArchive now asks main (bg-agent-live-job) first and refuses, before detaching anything, on a live job or an unknown answer. getStatus matches descriptor ids in any case. Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
|
Thanks. Pushed Blocking
Non-blocking
|
devsuitup
left a comment
There was a problem hiding this comment.
Review at 8b3e7ef. The blocker of 37ebdb4 is fixed: a cached live job whose state.json or directory is missing now refuses the delete, and archiving a folder refuses (or stops first) a live background job's session. The branch is level with main. 346 tests in the 36 related files pass locally on Node 24, plus 179 in 14 complementary files (2 Linux-only skipped).
The reviewer asked for changes because findSession and the descriptor comparison in cli-session-state.js (~205, ~225, ~246) still compare session ids strictly, so a mixed-case descriptor updates the status cache but misses the busy-to-idle rescan. I read it and it is real, but I do not treat it as blocking: it concerns the rescan of a session whose descriptor id differs in case from the app's id, the CLI writes lowercase UUIDs, and the delete guard, which is the safety property, no longer depends on it. Please fix it in this PR or a follow-up.
Non-blocking
cli-session-state.js: normalise the id infindSession, the descriptor comparison and the rescan keys the same way as instatusKey.- Missing tests: the negative cached-session case (a live entry of another session must not refuse), uppercase descriptor insertion and removal, and the new IPC route in
test/bg-agents-ipc.test.js. docs/background-agents.md(~134) says a folder archive refuses a live job unconditionally, butpublic/sidebar.js(~1167) skips the check when "Archive the sessions" is unchecked. Align the text or the code.main.js(~2449, ~2462, ~2491) still has rationale comments; they belong indocs/background-agents.md, at most a one-line pointer in the code.
Not run: Linux Node 20/22 with c8, lint, fresh CI. The CI runs on this head still need to be approved; I am doing that now, and the merge waits for them to be green.
A graphical replacement for the
claude agentsTUI: a dedicated Agents view that lists the sessions the Claude daemon runs in the background, shows what each one does, and attaches to, stops, respawns, deletes or dispatches them.What it does
--bgsessions plus the interactive sessions running outside this Switchboard, read from~/.claude/jobs/*/state.jsonand the CLI's session descriptors, reconciled byclaude agents --json --all. Open it with the people icon in the sidebar filter row orCtrl/Cmd+Shift+A(rebindable)..claude/worktrees/<name>or anygit worktree add); a Worktrees option (on by default) adds a second level per worktree for projects that have several.claude attach <id>in a terminal tab keyed by the session's real id; closing it detaches (Ctrl+Z, 2 s grace, then kill), it never stops the job. A click in the sidebar on a session the daemon runs attaches instead of asking to resume; such sessions carry abgbadge.workingorblocked) can only be stopped or attached to; it is never resumed or forked. New agent opens a dispatch dialog (prompt, name, project, agent, permission options).~/.claude/jobs/and thekind: "bg"descriptor are undocumented interfaces: if the daemon does not answer, the view falls back to the files and shows a banner. Canary tests pin the observed shapes (CLI 2.1.285).Design notes:
.ai/contexts/bg-agents.md; user doc:docs/background-agents.mdContext:
.ai/contexts/bg-agents.md(includes "Known limits"); user doc:docs/background-agents.mdFound while building
blocked(a live job waiting on input, treated as live everywhere) andfailed(finished).claude --bgprintsbackgrounded · <id> · <name>with the id in ANSI colour even when piped; a never-trusted cwd makes it refuse ("Workspace not trusted").window-framelessinset lists (right and left) so New agent stays clear of them, and its labels areno-drag.#mainrun past the window edge in narrow windows (it has nomin-width);#main { min-width: 0 }, and the header wraps its controls..new-session-dialog, which has no height limit; it gets its own class (max-height+overflow-y: auto) and scrolls on short screens. The shared class is left alone on purpose.Known limits (details in the context doc)
dispatcherrors can carry login-shell noise ahead of the CLI message; a noise line that is itself a valid JSON array can win the list parse;MAX_JOBS(200) truncates by id, not recency; interactive-descriptor liveness is pid-only; resolved project/worktree roots are cached until the window closes; a submodule or bare repo is its own project;runVerb's live-guard passes rm/respawn when the roster lacks the job (unreachable from the UI).https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo